Skip to content

refactor(daemon): decouple extension activation refresh - #10991

Open
callmeYe wants to merge 3 commits into
mainfrom
codex/extension-activation-explicit-refresh
Open

refactor(daemon): decouple extension activation refresh#10991
callmeYe wants to merge 3 commits into
mainfrom
codex/extension-activation-explicit-refresh

Conversation

@callmeYe

@callmeYe callmeYe commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Extension activation operations now complete after their activation policy is durably committed instead of directly refreshing every active session. A new extension_activation_explicit_refresh capability lets clients distinguish this contract from older daemons. The Web Shell uses that capability to submit a current-workspace refresh after activation succeeds without polling the refresh operation or keeping the Extension page busy. The independent 30-second generation reconciler remains unchanged.

Why it's needed

Refreshing every active session can rebuild and retransmit a large command and Skill snapshot, so coupling that work to a simple activation file update makes the activation operation slow and mixes runtime-refresh warnings into an otherwise successful policy operation. Separating the operations gives callers control over immediate application while preserving eventual generation reconciliation.

Reviewer Test Plan

How to verify

Start a daemon with an installed Extension and an active session, change the Extension's global or workspace activation, and poll the returned operation. Confirm that activation reaches succeeded after the policy commit without entering reconciling, without refreshed or failed result fields, and without directly refreshing the session. Submit the independent workspace refresh and confirm that it owns runtime refresh separately. In Web Shell, confirm that the activation control unlocks before the submitted refresh finishes; an older daemon without the capability must not receive a duplicate refresh.

Local verification: npm run build; npm run typecheck; focused ESLint; 51 Extension management route tests; 14 Extension controller tests; 2 capability documentation contract tests; 4 Web Shell activation-refresh tests; and the 1,176-test daemon server file. The full server run's three unrelated socket/timeout flakes passed when rerun serially.

Evidence (Before & After)

N/A — behavior and operation-contract change with automated coverage; no visual layout change.

Tested on

OS Status
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

Environment (optional)

Local Node.js 22 workspace with daemon HTTP route tests and jsdom Web Shell tests.

Risk & Scope

  • Main risk or tradeoff: callers that require immediate application must check extension_activation_explicit_refresh and submit the existing refresh operation. The Web Shell refreshes only its current workspace; other workspaces can retain the previous activation until the next 30-second generation-reconciler pass, with failures retried later.
  • Not validated / out of scope: no production-instance toggle was performed; the 30-second generation reconciler and Extension install, update, uninstall, and Extension-owned Skill refresh behavior are unchanged.
  • Breaking changes / migration notes: wire schemas are unchanged. New clients use the capability to avoid duplicate refresh against older daemons; older clients connected to a new daemon receive the faster commit-only activation behavior and eventual poller convergence.

Linked Issues

N/A

中文说明

本 PR 做了什么

Extension activation operation 现在会在 activation 策略持久化提交后完成,不再直接刷新所有活动 session。新增 extension_activation_explicit_refresh capability,供调用方区分该契约与旧 daemon。Web Shell 在 activation 成功后根据该 capability 向当前 workspace 提交 refresh,但不轮询 refresh operation,也不会继续锁定 Extension 页面。独立的 30 秒 generation reconciler 保持不变。

为什么需要

刷新所有活动 session 会重建并传输较大的命令与 Skill 快照。把这项工作耦合到简单的 activation 文件写入,会拖慢 activation operation,并把 runtime refresh warning 混入已经成功的策略 operation。拆分两个 operation 后,调用方可以决定是否立即生效,同时保留 generation 的最终收敛。

Reviewer Test Plan

如何验证

启动一个安装了 Extension 且存在活动 session 的 daemon,修改全局或 workspace activation,并轮询返回的 operation。确认 activation 在策略提交后直接进入 succeeded,不进入 reconciling,结果不包含 refreshedfailed,也不直接刷新 session。随后提交独立的 workspace refresh,确认 runtime refresh 由它单独负责。在 Web Shell 中确认 activation 控件在 refresh 完成前已经解锁;连接不包含该 capability 的旧 daemon 时不得重复提交 refresh。

本地验证:npm run buildnpm run typecheck;定向 ESLint;51 个 Extension management 路由测试;14 个 Extension controller 测试;2 个 capability 文档契约测试;4 个 Web Shell activation-refresh 测试;以及包含 1,176 个用例的 daemon server 测试文件。完整 server 测试中 3 个无关 socket/timeout 抖动用例在串行复跑后通过。

前后证据

N/A——这是行为与 operation 契约变更,没有视觉布局变化,已有自动化覆盖。

测试平台

OS 状态
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

环境(可选)

本地 Node.js 22 workspace,运行 daemon HTTP 路由测试与 jsdom Web Shell 测试。

风险与范围

  • 主要风险或取舍:需要立即生效的调用方必须检查 extension_activation_explicit_refresh 并提交现有 refresh operation。Web Shell 只刷新当前 workspace;其他 workspace 最长可能到下一轮 30 秒 generation reconciler 才应用新 activation,失败时由后续轮次重试。
  • 未验证或不在范围内:未在生产实例上执行开关;30 秒 generation reconciler,以及 Extension 安装、更新、卸载和 Extension 内部 Skill refresh 行为均未改变。
  • Breaking change / 迁移说明:wire schema 不变。新客户端通过 capability 避免对旧 daemon 重复 refresh;旧客户端连接新 daemon 时会获得更快的 commit-only activation,并由 poller 最终收敛。

关联 Issue

N/A

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@callmeYe

callmeYe commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

E2E test report

Validated locally on macOS with Node.js 22.

  • Activation contract: global/workspace, singular/batch, legacy/V2, and override clear operations commit without directly calling session refresh; terminal results omit refresh counts.
  • Failure isolation: pending and rejected runtime-refresh mocks do not delay or downgrade activation success.
  • Explicit application: the workspace refresh operation remains independent and advances the applied generation after success.
  • Eventual convergence: the unchanged 30-second generation reconciler still detects and applies pending generations.
  • Web Shell: a daemon advertising extension_activation_explicit_refresh receives a current-workspace refresh submission after activation; the page unlocks without polling that refresh. Older daemons receive no duplicate refresh, and submission failure does not roll back activation.
  • Regression coverage: Extension controller and route suites confirm install/update/uninstall and Extension-owned Skill reconciliation remain intact.

Commands/results: Extension route/controller/capability tests 67 passed; Web Shell behavior tests 4 passed; full daemon server file 1,173 passed with 3 unrelated concurrent socket/timeout flakes, all 3 passing on serial rerun; npm run build, npm run typecheck, focused ESLint, and git diff --check passed.

Not run against a production instance; no production Extension state was changed.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — all required headings present, Tested-on filled in, full Chinese translation.

Problem: partly substantiated, and one half of the stated motivation doesn't survive reading the code.

The latency coupling is real. Activation currently runs refreshExtensionsForAllSessions across every registered runtime inside the operation, serialized through the concurrency-1 runtimeReconciliationQueue, and the codebase already carries a RECONCILE_SLOW_MS warning path for exactly this. Pulling that work off a policy file write is a legitimate goal. There's no measurement behind it though — "makes the activation operation slow" has no before/after number.

The second claim, that coupling "lets refresh failures downgrade an otherwise successful policy commit", isn't what the code does. All three refresh-failure paths already land on succeeded_with_warnings, never failed: the multi-runtime Promise.all catches rejections into a rejected result, the single-workspace path is wrapped in catch (refreshErr), and the outer handler special-cases committedGeneration !== undefined into a post_commit_failed warning plus succeeded_with_warnings. There's even an in-code comment saying a failed notification must not turn a committed mutation into a failed operation. status: 'failed' is only reachable when the commit never happened. I'd drop or re-evidence that half of the rationale — it's the part that makes this read as a correctness fix rather than a latency refactor.

Direction: aligned. Capability-gating a contract change is the established pattern on this surface, and the daemon-side mechanism isn't new: skipRefresh already exists on the controller options and POST /extensions/check-updates already passes it, so this extends an existing option instead of adding one. The new tag is registered unconditionally and is genuinely read by the Web Shell client, so it isn't a dead switch. CHANGELOG: no direct reference, but Extension management and the daemon protocol are actively developed areas.

Size: cross-package (packages/cli + packages/web-shell), so Stage 0 core paths apply. Production logic 56 lines (capabilities 4, extensions controller 12, extension routes 21, ExtensionsManagerPage 19); tests 462 lines; docs 52 lines. Well under the 500-production-line refactor hard block and under the 1000-line advisory, so no size gate fires. Noting it for maintainer awareness only because it's a refactor spanning two packages, which puts it under the Tier 2 confidence bar.

Approach: scope is tight and I couldn't find a smaller version of it. All seven skipRefresh: true additions land on activation routes only — global batch and singular, workspace batch and singular, the workspace DELETE clear, and the two legacy enable/disable routes. Install, update and uninstall keep calling globalReconciliationOptions() untouched, which matches the PR's claim. The deleted workspaceReconciliationOptions() helper had exactly two call sites, both rewritten here, so the deletion is complete rather than leaving dead code. The client side reuses the existing extensions.manage.refreshFailed key (present in both en and zh) and correctly extends the useCallback deps.

One question I couldn't settle from the diff, and it's the main thing I'd want answered before merging: the client-side remedy is narrower than the behaviour it replaces. PUT /extensions/activation and PUT /extensions/:extensionId/activation previously passed refreshRuntimes: () => workspaceRegistry.listAll(). The Web Shell now answers a global default change with workspaceByCwd(workspace.workspaceCwd).refreshExtensionRuntime(...), which resolves to POST /workspaces/:workspace/extensions/refreshrefreshRuntimes: [runtime]. So a global activation change made from the Web Shell immediately refreshes only the current workspace.

I did confirm the 30-second poller closes the gap — it filters listAll() by appliedGenerationByWorkspaceId !== generation, and onRuntimeReconciled never fires under skipRefresh, so every runtime stays pending and gets picked up. But that converts an immediate all-runtime guarantee into a ≤30s eventual one, and there's no client-callable "refresh all runtimes" operation to substitute. It's most pointed when disabling an extension globally: for up to 30 seconds it stays live in other workspaces' sessions while the UI already reports success.

Risk: no Stage 1e high-risk path match. The elevated item is the multi-workspace consistency window above, which is a product decision rather than a code defect.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 所有必填小标题齐全,测试平台已填写,中文翻译完整。

问题: 部分成立,而且动机里有一半经不起读代码。

延迟耦合是真实存在的。activation 目前会在 operation 内部对所有已注册 runtime 执行 refreshExtensionsForAllSessions,并且被并发度为 1 的 runtimeReconciliationQueue 串行化;代码里本来就有一条 RECONCILE_SLOW_MS 告警路径正是为此而设。把这部分工作从一次策略文件写入里摘出来,目标是合理的。但没有给出任何度量——"让 activation operation 变慢"没有 before/after 数字支撑。

第二个说法,即耦合"会让 refresh 失败把一个本来成功的策略提交降级",与代码实际行为不符。三条 refresh 失败路径全都落在 succeeded_with_warnings,绝不会是 failed:多 runtime 的 Promise.all 会把 rejection 收成 rejected 结果;单 workspace 分支包在 catch (refreshErr) 里;外层处理还对 committedGeneration !== undefined 专门给出 post_commit_failed 告警加 succeeded_with_warnings。代码里甚至有一句注释明确写着:通知失败不能把一个已提交的变更变成失败的 operation。只有在提交根本没发生时才可能走到 status: 'failed'。建议把这半个理由删掉或补上证据——正是它让这个 PR 看起来像在修正确性 bug,而不是一次延迟重构。

方向: 对齐。用 capability 来门控契约变更是这个接口面既有的做法;而且 daemon 侧的机制并不是新东西:skipRefresh 本来就存在于 controller options 上,POST /extensions/check-updates 已经在用它,所以这是扩展既有选项而非新增。新 tag 是无条件注册的,并且确实被 Web Shell 客户端读取,因此不是死开关。CHANGELOG:没有直接对应条目,但 Extension 管理与 daemon 协议都是活跃开发区域。

规模: 跨包(packages/cli + packages/web-shell),因此适用 Stage 0 核心路径。生产逻辑 56 行(capabilities 4、extensions controller 12、extension routes 21、ExtensionsManagerPage 19);测试 462 行;文档 52 行。远低于 500 生产行的 refactor 硬阻断阈值,也低于 1000 行大 PR 提示线,所以没有触发任何规模闸门。之所以提请维护者注意,只是因为它是一个跨两个包的 refactor,因此落在 Tier 2 的信心门槛之下。

方案: 范围收得很紧,我找不到更小的实现版本。七处 skipRefresh: true 全部只落在 activation 路由上——全局批量与单个、workspace 批量与单个、workspace 的 DELETE 清除,以及两条 legacy enable/disable 路由。install、update、uninstall 仍然照旧调用 globalReconciliationOptions(),与 PR 的声明一致。被删掉的 workspaceReconciliationOptions() 辅助函数恰好只有两个调用点,且都在本 diff 中被改写,所以删除是完整的,没有留下死代码。客户端复用了已有的 extensions.manage.refreshFailed 文案键(中英文都存在),并正确扩充了 useCallback 依赖。

有一个我无法从 diff 判断的问题,也是合并前我最希望得到答复的一点:客户端的补救措施比它所替代的行为范围更窄。 PUT /extensions/activationPUT /extensions/:extensionId/activation 之前传的是 refreshRuntimes: () => workspaceRegistry.listAll()。现在 Web Shell 对一次全局默认值变更的回应是 workspaceByCwd(workspace.workspaceCwd).refreshExtensionRuntime(...),它解析到 POST /workspaces/:workspace/extensions/refreshrefreshRuntimes: [runtime]。也就是说,从 Web Shell 发起的全局 activation 变更,只会立即刷新当前 workspace。

我确实确认了 30 秒轮询器能补上这个缺口——它按 appliedGenerationByWorkspaceId !== generation 过滤 listAll(),而在 skipRefreshonRuntimeReconciled 永远不会触发,因此每个 runtime 都保持 pending 并会被捞起来。但这把一个"立即覆盖所有 runtime"的保证变成了"≤30 秒最终一致",而且没有任何客户端可调用的"刷新所有 runtime"操作可以替代。在全局禁用某个 Extension 时这一点最尖锐:最长 30 秒内,它在其他 workspace 的会话里仍然生效,而 UI 已经显示成功。

风险: Stage 1e 没有命中高风险路径。需要留意的点是上面这个多 workspace 一致性窗口,它属于产品决策,而不是代码缺陷。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 8b0e0e7944d4a8edb93d5005c849a8a32c63fc6c · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 1f06d58, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

capabilities

field PR base (before) this PR (after)
features[] "extension_activation_explicit_refresh"

Qwen Code · serve A/B

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 1f06d58. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

⚠️ One or more scenarios failed to render on this head, so this preview may be missing views — see the workflow run. The composites below are the scenarios that did render.

terminal-turn-error-copy-narrow-dark before/after

terminal-turn-error-copy-narrow-light before/after

workflow-page-running-dark before/after

workflow-page-running-light before/after

workflow-page-saved-dark before/after

workflow-page-saved-detail-dark before/after

workflow-page-saved-detail-light before/after

workflow-page-saved-light before/after

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Code review

Read against 8b0e0e7944d4a8edb93d5005c849a8a32c63fc6c. My independent proposal before opening the diff was: keep the commit as the durability point, reuse the existing skipRefresh controller option on activation routes only, advertise a capability so old clients don't double-refresh, and have the client fire the existing refresh operation without awaiting it. That is essentially what this PR does, and it reuses the right primitives — skipRefresh is already on the controller options and already used by POST /extensions/check-updates, and the client reuses the existing extensions.manage.refreshFailed key, which is present in both the en and zh catalogs. So no parallel mechanism was invented. The routing is also correctly scoped: all seven skipRefresh: true additions are activation routes, and install/update/uninstall still call globalReconciliationOptions().

One blocker, one decision that needs a human, and some accuracy points.

Blocking — the new capability tag was not added to the integration test's baseline list

integration-tests/cli/qwen-serve-routes.test.ts has an advertises all baseline capabilities case that asserts the full tag array with toEqual, and extension_batch_activation_v2 sits in that hardcoded list. The new tag was never added, so the advertised array is one longer than expected and the check is red. This is the cause of the Integration Tests (no-AK, No Sandbox) failure below — not infra noise. The fix is a one-line insertion of 'extension_activation_explicit_refresh' in the same position it occupies in the registry.

I enumerated every hardcoded copy of that list to be sure this is the only miss, and it is: the registry itself, packages/cli/src/serve/server.test.ts, the tag list in docs/developers/qwen-serve-protocol.md, and the toContain assertion in workspace-qualified-extensions.test.ts were all updated. The PR body's local verification list covers route tests, controller tests, capability doc contract tests, Web Shell tests and the daemon server file — but not the integration suite, which is why this one slipped through.

Needs a product decision — a global activation change now refreshes only the current workspace

Covered in the gate comment, restating the code path concretely because it's the substance of the change. PUT /extensions/activation and PUT /extensions/:extensionId/activation previously passed refreshRuntimes: () => workspaceRegistry.listAll(). The Web Shell reaches the global route through scope === 'user'setExtensionDefaultActivation(...), and then answers with workspaceByCwd(workspace.workspaceCwd).refreshExtensionRuntime(...), which is POST /workspaces/:workspace/extensions/refreshrefreshRuntimes: [runtime].

I traced the convergence path and it does hold: the poller filters workspaceRegistry.listAll() by appliedGenerationByWorkspaceId.get(runtime.workspaceId) !== generation, and because onRuntimeReconciled is only invoked on a successful reconcile, skipRefresh leaves every runtime pending, so the next 30-second tick refreshes all of them. Nothing is lost permanently.

What changes is the guarantee. Workspace-scoped activation is unaffected — it refreshed only [runtime] before and effectively does the same now. Global default activation goes from immediate-all-runtimes to ≤30s-eventual, and there is no client-callable operation that refreshes every runtime, so a client cannot opt back into the old behaviour. The sharpest case is a global disable: the UI reports success while the extension's commands, skills and MCP servers stay live in other workspaces' sessions for up to 30 seconds. Either submit the refresh per affected runtime, or state the window explicitly in the user-facing doc — right now eventual convergence is documented in the developer protocol and SDK pages, while docs/users/qwen-serve.md still reads as though the batch routes apply on completion.

Accuracy points

  • The "refresh failures downgrade an otherwise successful policy commit" rationale is contradicted by the controller. Every refresh-failure path already yields succeeded_with_warnings; status: 'failed' is reachable only when committedGeneration === undefined, i.e. the commit never happened. Detail in the gate comment.
  • "Wire schemas are unchanged" is true of the shape but not of the payload: activation operation results stop carrying refreshed and failed. I grepped the consumers and nothing in-repo reads those off the operation result — packages/sdk-typescript/src/daemon/ui/terminal.ts reads them off the extensions_changed broadcast event, and the bridge reads them off refreshExtensionsForAllSessions return values — so no code breaks here. A third-party client polling the operation would see the fields vanish, though, so the body is better off saying that plainly.
  • The protocol doc tells clients to "wait for the activation operation to commit and then submit the independent runtime-refresh operation", naming both refresh routes. That is accurate for POST /workspaces/:workspace/extensions/refresh, which goes through sendOperation and returns a pollable handle, but the legacy POST /workspace/extensions/refresh is a plain synchronous handler returning the refresh result directly — not an operation. Worth disambiguating since both are named in the same sentence.

Non-blocking

docs/design/extension-management-v2.md picked up a broken reflow — the sentence now ends a line with "so only the winning" and continues "artifact commit activates a complete bundle." Purely cosmetic, and unrelated to the substance of that edit.

Everything else I checked held up: the fire-and-forget refresh is dispatched after load(true) and after the success message, with .catch attached, so the control unlocks as intended and a refresh failure still surfaces without an unhandled rejection; the useCallback dep array is correctly extended with activationRequiresExplicitRefresh and connection.clientId; and the tag count in 00-index.md was bumped 151→152 while correctly leaving "44 conditional tags" alone, since the new tag is registered unconditionally.

Flow after this change

sequenceDiagram
    participant P1 as Web Shell ExtensionsManagerPage
    participant P2 as activation route
    participant P3 as extensions controller
    participant P4 as Extension Store generation
    participant P5 as runtime bridge sessions
    participant P6 as 30s generation poller
    P1->>P2: activation change (global or workspace scope)
    P2->>P3: sendOperation with skipRefresh true
    P3->>P4: durable policy commit, generation bumps
    P3-->>P1: operation succeeded, no reconciling phase
    P1->>P5: refreshExtensionRuntime, current workspace only
    Note over P5: sessions in other workspaces are untouched here
    P6->>P4: read store generation
    P6->>P5: refresh every runtime whose applied generation lags
Loading

Testing

This is an unattended CI run, so nothing was built or executed here — the evidence below is this PR's own CI on the reviewed commit, read through the API. No tmux or real-scenario testing was driven.

Integration Tests (no-AK, No Sandbox) is red and it is PR-caused, not pre-existing infra noise: the assertion diff names extension_activation_explicit_refresh as the extra received element, which is exactly the tag this PR registers. Excerpt from the failing job (actions/runs/33834675931/job/100904690134):

FAIL  cli/qwen-serve-routes.test.ts > qwen serve — capabilities envelope > advertises all baseline capabilities
AssertionError: expected [ 'health', 'daemon_status', …(127) ] to deeply equal [ 'health', 'daemon_status', …(126) ]
- Expected
+ Received
@@ -76,10 +76,11 @@
     "workspace_skill_settings_batch_toggle",
     "extension_batch_activation_v2",
+   "extension_activation_explicit_refresh",
     "workspace_skill_manage",
❯ cli/qwen-serve-routes.test.ts:340:7

 Test Files  1 failed | 20 passed (21)
      Tests  1 failed | 173 passed (174)

The unit suite, lint, typecheck and the serve A/B job had not finished when this was fetched — Test (ubuntu-latest, Node 22.x), Lint & Static (ubuntu-latest, Node 22.x), Serve A/B, Capture web-shell visuals, OpenTUI no-flicker gate, Real daemon E2E / Java 11 and macos-latest / Java 21 were still in progress. Not verified: whether the changed unit tests, typecheck and lint pass, because those checks were still running. I did not poll for them.

Not verified: the author's local results quoted in the PR body (51 route tests, 14 controller tests, 2 capability doc contract tests, 4 Web Shell activation-refresh tests, the 1,176-test daemon server file, and three socket/timeout flakes passing on serial rerun). That is the author's claim, tested on macOS only, not evidence this review re-ran.

Final CI results for 8b0e0e7 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Integration Tests (no-AK, No Sandbox) ❌ failure
macos-latest / Java 21 🚫 cancelled
web-shell E2E Smoke (ubuntu-latest, Node 22.x) 🚫 cancelled
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
OpenTUI no-flicker gate ✅ success
Real daemon E2E / Java 11 ✅ success
Secret scan (TruffleHog) ✅ success
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
TUI parity snapshots (ink vs opentui) ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
windows-latest / Java 21 ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Bot orchestration jobs (triage, review-pr, Classify PR, assign, label, authorize, the publish-* and precheck-pr pair, tmux-testing, verify) are omitted from the table — they are not PR CI.

Sandboxed verification would settle what CI cannot: @qwen-code /verify — that activation now reaches succeeded with no reconciling phase and no refreshed/failed fields is assertable from the route tests, but that the 30-second poller actually converges a second workspace after a global default change is not observable anywhere in this diff, and the suite would pass identically with the Web Shell refresh block deleted, since no test asserts another workspace catching up. @qwen-code /tmux covers the client half: that the activation control genuinely unlocks before the submitted refresh finishes, and that a daemon without the capability does not receive a duplicate refresh.

中文说明

代码审查

基于 8b0e0e7944d4a8edb93d5005c849a8a32c63fc6c 审查。在看 diff 之前,我自己的方案是:把 commit 作为持久化点,仅在 activation 路由上复用已有的 skipRefresh controller 选项,用一个 capability 声明契约以避免旧客户端重复刷新,并让客户端发起已有的 refresh operation 但不等待它。这个 PR 基本就是这么做的,而且复用了正确的原语——skipRefresh 本来就在 controller options 上,POST /extensions/check-updates 已经在用;客户端复用了已有的 extensions.manage.refreshFailed 文案键,中英文目录里都存在。所以没有另造一套并行机制。路由范围也是对的:七处 skipRefresh: true 全在 activation 路由上,install/update/uninstall 仍然调用 globalReconciliationOptions()

一个阻断项、一个需要人来拍板的决策,以及几处表述准确性问题。

阻断——新 capability tag 没有加进集成测试的基线列表

integration-tests/cli/qwen-serve-routes.test.ts 里的 advertises all baseline capabilitiestoEqual 断言完整的 tag 数组,而 extension_batch_activation_v2 就在这个硬编码列表里。新 tag 没有加进去,所以实际数组比期望多一项,检查就红了。这正是下面 Integration Tests (no-AK, No Sandbox) 失败的原因——不是基础设施抖动。修法是在该数组里按 registry 中的相同位置插入一行 'extension_activation_explicit_refresh'

为了确认这是唯一的遗漏,我把该列表的所有硬编码副本都列了一遍,确实只有这一处漏了:registry 本身、packages/cli/src/serve/server.test.tsdocs/developers/qwen-serve-protocol.md 的 tag 列表、以及 workspace-qualified-extensions.test.ts 里的 toContain 断言都更新了。PR 正文的本地验证清单覆盖了路由测试、controller 测试、capability 文档契约测试、Web Shell 测试和 daemon server 文件,但没有包含集成测试套件,这就是漏掉的原因。

需要产品决策——全局 activation 变更现在只刷新当前 workspace

闸门评论里已经讲过,这里把代码路径具体重述一遍,因为它是本次改动的实质。PUT /extensions/activationPUT /extensions/:extensionId/activation 之前传的是 refreshRuntimes: () => workspaceRegistry.listAll()。Web Shell 通过 scope === 'user'setExtensionDefaultActivation(...) 走到全局路由,然后用 workspaceByCwd(workspace.workspaceCwd).refreshExtensionRuntime(...) 回应,也就是 POST /workspaces/:workspace/extensions/refreshrefreshRuntimes: [runtime]

我跟了一遍收敛路径,它确实成立:轮询器按 appliedGenerationByWorkspaceId.get(runtime.workspaceId) !== generation 过滤 workspaceRegistry.listAll(),而 onRuntimeReconciled 只在 reconcile 成功时才调用,所以 skipRefresh 会让每个 runtime 都保持 pending,下一个 30 秒 tick 会把它们全部刷新。不会有永久丢失。

变化的是保证强度。workspace 范围的 activation 不受影响——它之前也只刷新 [runtime],现在实质相同。全局默认 activation 从"立即覆盖所有 runtime"变成"≤30 秒最终一致",而且没有任何客户端可调用的操作能刷新全部 runtime,因此客户端无法主动退回旧行为。最尖锐的是全局禁用:UI 已经报成功,而该 Extension 的命令、Skill 与 MCP server 在其他 workspace 的会话里还会存活最长 30 秒。要么按受影响的 runtime 逐个提交 refresh,要么在面向用户的文档里明确写出这个窗口——目前最终收敛只写在开发者协议和 SDK 文档里,而 docs/users/qwen-serve.md 读起来仍然像是批量路由在完成时即生效。

表述准确性

  • "refresh 失败会把本来成功的策略提交降级"这个理由与 controller 实现相矛盾。所有 refresh 失败路径都已经产出 succeeded_with_warnings;只有在 committedGeneration === undefined(即提交根本没发生)时才可能走到 status: 'failed'。细节见闸门评论。
  • "wire schema 不变"在形状上成立,但在载荷上不成立:activation operation 的 result 不再带 refreshedfailed。我查了消费方,仓库内没有任何代码从 operation result 读这两个字段——packages/sdk-typescript/src/daemon/ui/terminal.ts 读的是 extensions_changed 广播事件,bridge 读的是 refreshExtensionsForAllSessions 的返回值——所以本仓库不会因此出错。但轮询该 operation 的第三方客户端会看到字段消失,正文最好直说。
  • 协议文档让客户端"等待 activation operation 提交后再提交独立的 runtime-refresh operation",并同时点名了两条 refresh 路由。这对 POST /workspaces/:workspace/extensions/refresh 是准确的(它走 sendOperation,返回可轮询的 handle),但 legacy 的 POST /workspace/extensions/refresh 是一个普通同步 handler,直接返回 refresh 结果,并不是 operation。既然同一句里点名了两条路由,建议区分说明。

非阻断

docs/design/extension-management-v2.md 出现了一处换行错乱——句子在 "so only the winning" 处断行,下一行才接 "artifact commit activates a complete bundle."。纯属排版问题,与该处编辑的实质内容无关。

其余我检查过的地方都成立:fire-and-forget 的 refresh 是在 load(true) 之后、成功消息之后才发起的,并且挂了 .catch,所以控件按预期解锁,refresh 失败仍会显示,也不会产生未处理的 rejection;useCallback 依赖数组正确补上了 activationRequiresExplicitRefreshconnection.clientId00-index.md 的 tag 数从 151 改到 152,同时正确地保留了"44 conditional tags",因为新 tag 是无条件注册的。

测试

这是一次无人值守的 CI 运行,因此本地没有构建或执行任何代码——下面的证据来自本 PR 自己在被审查 commit 上的 CI,通过 API 读取。没有驱动 tmux 或真实场景测试。

Integration Tests (no-AK, No Sandbox) 是红的,且由本 PR 引起,不是既有的基础设施抖动:断言 diff 明确把 extension_activation_explicit_refresh 列为多出来的接收项,而这正是本 PR 注册的 tag。失败 job 的日志摘录见上方英文部分。

抓取时单元测试套件、lint、typecheck 与 serve A/B job 尚未结束——Test (ubuntu-latest, Node 22.x)Lint & Static (ubuntu-latest, Node 22.x)Serve A/BCapture web-shell visualsOpenTUI no-flicker gateReal daemon E2E / Java 11macos-latest / Java 21 仍在运行。未验证:改动后的单元测试、typecheck 与 lint 是否通过,因为这些检查当时还在跑。我没有轮询等待。

未验证:PR 正文引用的作者本地结果(51 个路由测试、14 个 controller 测试、2 个 capability 文档契约测试、4 个 Web Shell activation-refresh 测试、1,176 个用例的 daemon server 文件,以及 3 个 socket/timeout 抖动串行复跑通过)。那是作者的自述,且仅在 macOS 上测过,不是本次审查重跑的证据。

CI 表格见上方英文部分(由 finalize 工作流在 CI 结束后就地更新)。机器人编排 job(triagereview-prClassify PRassignlabelauthorizepublish-*precheck-prtmux-testingverify)不属于 PR CI,已从表中省略。

沙箱验证可以补上 CI 补不了的部分:@qwen-code /verify——activation 现在能到 succeeded、没有 reconciling 阶段、result 里没有 refreshed/failed,这些路由测试可以断言;但"全局默认值变更后 30 秒轮询器确实会收敛第二个 workspace"在整个 diff 里无处可观测,而且把 Web Shell 那段 refresh 代码删掉,测试套件也会照样通过,因为没有任何用例断言其他 workspace 会追上。@qwen-code /tmux 覆盖客户端那一半:activation 控件是否真的在提交的 refresh 完成前解锁,以及不含该 capability 的 daemon 是否不会收到重复 refresh。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 8b0e0e7944d4a8edb93d5005c849a8a32c63fc6c · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 2/5 — the shape of this change is right and the diff is disciplined, but CI is red on a tag list the PR forgot to update, and one behaviour change is a product decision I shouldn't make from a diff.

My independent proposal before reading the code was almost exactly what this PR does: keep the commit as the durability point, reuse the controller's existing skipRefresh on activation routes only, gate the new contract behind a capability, and let the client fire the existing refresh without awaiting it. That agreement is a good sign about the direction, but it isn't the bar — matching my guess doesn't make it verified.

What I'd say honestly: this is a well-built PR. Fifty-six production lines accomplish the decoupling, the seven skipRefresh additions are all on activation routes with install/update/uninstall genuinely untouched, the deleted helper had exactly the two call sites the diff rewrites, and the client reuses an existing i18n key in both locales instead of adding one. The documentation work is unusually thorough — five doc files, the registered-tag count bumped, and the conditional-tag count correctly left alone because the new tag is unconditional. If I picked this up in six months I'd thank the author rather than curse them.

I'm still not approving, for three reasons in order of weight.

The integration check is red and this PR caused it. advertises all baseline capabilities asserts the full tag array with toEqual, and the new tag was never added to that hardcoded list. The assertion diff names extension_activation_explicit_refresh as the extra received element. That's a one-line fix, and I confirmed it's the only hardcoded copy that was missed — but I can't approve over a failing integration check that is pointing directly at this diff. The PR's local verification list covers the route, controller, doc-contract, Web Shell and daemon server suites, not the integration suite, which is exactly the gap.

The multi-workspace question is not mine to settle. Global default activation goes from refreshing every runtime immediately to refreshing the current workspace immediately and the rest within ~30 seconds via the poller. I traced the poller and convergence does hold, so nothing is lost permanently — but the guarantee genuinely weakens, there's no client-callable way to refresh all runtimes, and the sharpest case is a global disable where the UI reports success while the extension stays live in other workspaces' sessions for up to half a minute. That may well be the right tradeoff for the latency win. "May well be" is my judgment, not a verification, and this is a cross-package refactor over core paths, where the bar is certainty or escalation. I'm not certain, so I'm escalating rather than signing off. No maintainer resolved deterministically here — the PR carries no area label, has no human reviewer yet, and no maintainer handle is configured — so I'm not going to guess a login; this needs a human to weigh the latency win against the consistency window.

Half the stated motivation is wrong, and that changes what this PR is. Refresh failures already produce succeeded_with_warnings on all three paths, and the code carries an explicit comment that a failed notification must not turn a committed mutation into a failed operation. So the "downgrade an otherwise successful policy commit" rationale doesn't hold. If that was part of what made this feel urgent, the honest framing is a latency refactor with a documented consistency tradeoff — a different conversation, and one worth having on accurate premises.

What would move me to approve: the integration-test line; either a per-affected-runtime refresh on the global path or an explicit statement in docs/users/qwen-serve.md that a global activation change is applied to other workspaces within ~30 seconds rather than on completion; and correcting the downgrade claim in the description. The two doc imprecisions (the legacy refresh route isn't an operation handle; the design-doc reflow) are non-blocking and can ride along or not.

Note on evidence: the unit suite, lint, typecheck and serve A/B job were still running when I fetched CI, and I did not poll them — so those results are unknown to this review, not assumed green. The finalize workflow updates the CI table in the review comment once they settle. Nothing was built or executed here; the review is static plus this PR's own CI read through the API.

Requesting changes on the blocking item and deferring the design question to a maintainer. 🙏

中文说明

Confidence: 2/5 —— 改动的形态是对的,diff 也很克制,但 CI 因为本 PR 漏改的一处 tag 列表而变红,而且有一个行为变更属于产品决策,不该由我从 diff 里拍板。

在读代码之前,我自己的方案与这个 PR 几乎完全一致:把 commit 作为持久化点,仅在 activation 路由上复用 controller 已有的 skipRefresh,用 capability 门控新契约,并让客户端发起已有的 refresh 而不等待它。这种一致说明方向是好的,但它不是评判标准——与我猜测相符并不等于已验证。

老实说:这是一个做得很扎实的 PR。56 行生产代码完成了这次解耦;七处 skipRefresh 全部落在 activation 路由上,install/update/uninstall 确实未被触及;被删除的辅助函数恰好只有 diff 中改写的那两个调用点;客户端复用了两种语言都已存在的 i18n 键,而不是新增一个。文档工作异常充分——五个文档文件、注册 tag 计数已更新,并且因为新 tag 是无条件的,正确地保留了 conditional tag 计数。如果半年后由我接手,我会感谢作者而不是抱怨。

我仍然不批准,按权重排列有三点理由。

集成检查是红的,而且由本 PR 引起。 advertises all baseline capabilitiestoEqual 断言完整的 tag 数组,而新 tag 从未加入那个硬编码列表。断言 diff 明确把 extension_activation_explicit_refresh 列为多出来的接收项。这是一行就能修的问题,我也确认了这是唯一被漏掉的硬编码副本——但我无法在一个正指向本 diff 的失败集成检查之上批准合并。PR 的本地验证清单覆盖了路由、controller、文档契约、Web Shell 与 daemon server 套件,唯独没有集成套件,这正是缺口所在。

多 workspace 的问题不该由我来定。 全局默认 activation 从"立即刷新所有 runtime"变成"立即刷新当前 workspace、其余在约 30 秒内由轮询器刷新"。我跟了轮询器的实现,收敛确实成立,所以不会有永久丢失——但保证强度确实变弱了,客户端没有任何可调用方式能刷新全部 runtime,而最尖锐的场景是全局禁用:UI 已报成功,而该 Extension 在其他 workspace 的会话里还会存活最长半分钟。为了延迟收益,这个取舍很可能值得。但"很可能值得"是我的判断,不是验证结果;而这是一个跨包、触及核心路径的 refactor,标准是"确定,否则上报"。我不确定,所以我选择上报而不是签字放行。这里没有确定性地解析出维护者——PR 没有领域标签,尚无人类 reviewer,也没有配置维护者 handle——所以我不会去猜一个登录名;这一点需要人来权衡延迟收益与一致性窗口。

动机里有一半是错的,而这改变了这个 PR 的性质。 三条 refresh 失败路径都已经产出 succeeded_with_warnings,代码里还有一句明确注释:通知失败不能把一个已提交的变更变成失败的 operation。所以"把本来成功的策略提交降级"这个理由不成立。如果它正是让这件事显得紧迫的一部分原因,那么诚实的表述应该是"一次带有已记录一致性取舍的延迟重构"——那是另一场讨论,而且值得在准确的前提下进行。

能让我转为批准的条件:补上集成测试那一行;在全局路径上按受影响的 runtime 逐个刷新,或在 docs/users/qwen-serve.md 中明确写出全局 activation 变更会在约 30 秒内应用到其他 workspace、而不是在完成时即生效;以及修正描述中的"降级"说法。两处文档不精确(legacy refresh 路由并非 operation handle;设计文档的换行错乱)属于非阻断项,可以顺手带上,也可以不带。

关于证据:抓取 CI 时单元测试套件、lint、typecheck 与 serve A/B job 仍在运行,我没有轮询等待——因此这些结果对本次审查是未知的,而不是被假定为绿。它们结束后,finalize 工作流会更新审查评论里的 CI 表格。本次没有构建或执行任何代码;审查方式为静态审查加上通过 API 读取本 PR 自己的 CI。

就阻断项提出修改请求,并把设计问题转交维护者。🙏

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 8b0e0e7944d4a8edb93d5005c849a8a32c63fc6c · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs one fix and one human decision before this can merge — details in my review comments above. 🙏

Blocking: integration-tests/cli/qwen-serve-routes.test.ts asserts the full baseline capability array with toEqual, and extension_activation_explicit_refresh was never added to that hardcoded list. That is the red Integration Tests (no-AK, No Sandbox) check — the assertion diff names this PR's own new tag as the extra received element. One-line insertion, in the same position the tag occupies in the registry. I checked every other hardcoded copy of that list (the registry, server.test.ts, qwen-serve-protocol.md, workspace-qualified-extensions.test.ts) and they were all updated, so this is the only miss.

Needs a maintainer's call: global default activation previously refreshed every runtime immediately; it now refreshes the current workspace immediately and the rest within ~30s via the generation poller. I traced the poller and convergence holds, so nothing is lost permanently, and the Web Shell's workspace-scoped activation path is unaffected. But the guarantee weakens, there is no client-callable operation that refreshes all runtimes, and the sharpest case is a global disable: the UI reports success while the extension stays live in other workspaces' sessions for up to 30 seconds. Either submit the refresh per affected runtime, or state the window explicitly in docs/users/qwen-serve.md. This is a cross-package refactor over core paths, so I'm escalating the tradeoff rather than signing off on it — no maintainer resolved deterministically (no area label, no human reviewer yet), so I'm not guessing a login.

Also worth correcting: the description says coupling "lets refresh failures downgrade an otherwise successful policy commit". All three refresh-failure paths already produce succeeded_with_warnings, and the code carries an explicit comment that a failed notification must not turn a committed mutation into a failed operation; status: 'failed' is only reachable when the commit never happened. The latency half of the motivation is real, this half isn't.

To be clear about what I'm not saying: the design itself is sound. Fifty-six production lines, all seven skipRefresh additions correctly confined to activation routes, install/update/uninstall untouched, the deleted helper's two call sites both rewritten here, an existing i18n key reused in both locales, and unusually thorough documentation. Non-blocking: the legacy POST /workspace/extensions/refresh is not an operation handle despite the protocol doc naming both routes in one sentence, and a doc reflow left a sentence broken mid-clause.

Note: the unit suite, lint, typecheck and serve A/B job were still running when I fetched CI and I did not poll them, so those results are unknown to this review rather than assumed green. Nothing was built or executed here — static review plus this PR's own CI read through the API.

中文说明

合并前需要一个修复和一个人工决策——细节见上方的审查评论。🙏

阻断项: integration-tests/cli/qwen-serve-routes.test.tstoEqual 断言完整的基线 capability 数组,而 extension_activation_explicit_refresh 从未加入那个硬编码列表。这就是 Integration Tests (no-AK, No Sandbox) 变红的原因——断言 diff 把本 PR 自己新增的 tag 列为多出来的接收项。一行插入即可,位置与该 tag 在 registry 中的位置一致。我检查了该列表的其他所有硬编码副本(registry、server.test.tsqwen-serve-protocol.mdworkspace-qualified-extensions.test.ts),它们都更新了,所以这是唯一的遗漏。

需要维护者拍板: 全局默认 activation 之前会立即刷新所有 runtime;现在只立即刷新当前 workspace,其余在约 30 秒内由 generation 轮询器刷新。我跟了轮询器实现,收敛确实成立,不会有永久丢失,而且 Web Shell 的 workspace 范围 activation 路径不受影响。但保证强度变弱了,客户端没有任何可调用操作能刷新全部 runtime,最尖锐的场景是全局禁用:UI 已报成功,而该 Extension 在其他 workspace 的会话里还会存活最长 30 秒。要么按受影响的 runtime 逐个提交 refresh,要么在 docs/users/qwen-serve.md 中明确写出这个窗口。这是一个跨包、触及核心路径的 refactor,所以我选择上报这个取舍而不是替它签字——此处没有确定性地解析出维护者(无领域标签、尚无人类 reviewer),因此我不会去猜登录名。

另外建议修正: 描述中说耦合"会让 refresh 失败把一个本来成功的策略提交降级"。三条 refresh 失败路径都已经产出 succeeded_with_warnings,代码里还有一句明确注释:通知失败不能把一个已提交的变更变成失败的 operation;只有在提交根本没发生时才可能走到 status: 'failed'。动机里关于延迟的那一半是真实的,这一半不是。

需要说清我没有在说什么:设计本身是可靠的。56 行生产代码,七处 skipRefresh 全部正确地限定在 activation 路由,install/update/uninstall 未被触及,被删辅助函数的两个调用点都在此改写,两种语言都复用了已有的 i18n 键,文档也相当充分。非阻断项:尽管协议文档在同一句里点名了两条 refresh 路由,legacy 的 POST /workspace/extensions/refresh 并不是 operation handle;另外一处文档换行错乱让句子在从句中间断开。

注意:抓取 CI 时单元测试套件、lint、typecheck 与 serve A/B job 仍在运行,我没有轮询等待,因此这些结果对本次审查是未知的,而不是被假定为绿。本次没有构建或执行任何代码——静态审查加上通过 API 读取本 PR 自己的 CI。

Qwen Code · qwen3.8-max-2026-09-02

@callmeYe

callmeYe commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed every item from the requested-changes review on the current head:

  • Added extension_activation_explicit_refresh to the strict integration capability list in eb0a9871af; the exact previously failing qwen-serve capability integration test passes against a freshly rebuilt bundle.
  • Kept the product decision that Web Shell refreshes only its current workspace after global activation. The user-facing contract now states that other workspaces may retain the previous activation until the next 30-second generation-reconciler pass, with failures retried later (1f06d58d6e).
  • Corrected the PR motivation to describe refresh warnings rather than implying the durable commit becomes failed.
  • Distinguished the synchronous legacy refresh response from the asynchronous workspace-qualified refresh operation, and repaired the awkward design-document line break (1f06d58d6e).

There are no review threads. The requested-changes review is anchored to 8b0e0e7944; current head is 1f06d58d6e.

@callmeYe
callmeYe dismissed qwen-code-ci-bot’s stale review September 4, 2026 04:25

Dismissed as stale after the current head fixed the missing capability expectation, documented the deliberate 30-second cross-workspace convergence window, corrected the refresh-warning motivation, and clarified both refresh response shapes. No review threads remain; the review targets 8b0e0e7 while current head is 1f06d58.

`extension_batch_activation_v2` adds `PUT /extensions/activation` and `PUT /workspaces/:workspace/extensions/activation`. Both accept 1–100 names in `extensionNames`, deduplicate them case-insensitively while preserving first-seen order, persist changed targets in one generation, and return one `202` operation handle. A target does not need to be installed when setting `enabled` or `disabled`: its name creates a desired-state declaration that is preserved when an Extension with that name is installed. The global route accepts `state: "enabled" | "disabled"`, writes V2 `defaultActivation`, and reconciles every registered runtime. The workspace route also accepts `"inherit"`, applies or clears exact overrides for the selected trusted runtime, and reconciles only that runtime. `inherit` does not declare an unknown name; an all-unknown clear reports `updated: false` and skips reconciliation. Singular activation routes remain installed-only and id-addressed.
`extension_batch_activation_v2` adds `PUT /extensions/activation` and `PUT /workspaces/:workspace/extensions/activation`. Both accept 1–100 names in `extensionNames`, deduplicate them case-insensitively while preserving first-seen order, persist changed targets in one generation, and return one `202` operation handle. A target does not need to be installed when setting `enabled` or `disabled`: its name creates a desired-state declaration that is preserved when an Extension with that name is installed. The global route accepts `state: "enabled" | "disabled"` and writes V2 `defaultActivation`; the workspace route also accepts `"inherit"` and applies or clears exact overrides for the selected trusted runtime. `inherit` does not declare an unknown name, and an all-unknown clear reports `updated: false`.

`extension_activation_explicit_refresh` means singular and batch activation operations finish after the durable policy commit without directly refreshing active sessions. Callers that need immediate application should wait for activation success and then submit either the synchronous primary-workspace `POST /workspace/extensions/refresh`, which returns refresh counts directly, or the selected workspace's asynchronous `POST /workspaces/:workspace/extensions/refresh`, which returns a separate operation handle. A refresh failure does not roll back or downgrade the activation result. Daemons without this capability already include runtime refresh in activation, so compatibility clients must not submit a second refresh. The independent 30-second generation reconciler remains enabled and normally applies the committed policy by its next pass; failed reconciliation is retried by later passes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R1-1: [certifies-falsely] [regression] The worked example in this file's operation-status section (~lines 496–519) shows an "operation": "activation" result carrying refreshed/failed counts and a reconcile_slow warning — a shape activation operations can no longer produce. This PR gives every activation route skipRefresh: true, and the controller's skipRefresh early-return (workspace-extensions-controller.ts:678) runs before the reconciliation phase that is the only place those fields and that warning are produced, so an integrator coding against this reference parses activation results expecting result.refreshed/result.failed or handles reconcile_slow for activation and misfires on every activation — the daemon never emits that shape anymore. The example now also contradicts the paragraph this PR adds here ("finish after the durable policy commit without directly refreshing active sessions"). Repoint the example at an operation that still performs runtime reconciliation (install/update/uninstall, adjusting result.status), or keep the activation example and drop refreshed/failed from its result, using a commit-originated warning.

Witness:

BASE: pre-change tests in this diff itself expected activation results carrying refreshed: 2, failed: 0
      (removed lines in workspace-qualified-extensions.test.ts)
PR:   workspace-qualified-extensions.test.ts -t 'without refreshing its runtime' -> Tests 1 passed
      pins expect(operation.result).not.toHaveProperty('refreshed') / ('failed')
中文说明

本文件 operation-status 小节(约 496–519 行)中的示例展示了 "operation": "activation" 的结果,其中带有 refreshed/failed 计数和 reconcile_slow 警告——而 activation operation 已经不可能再产生这种形状。本 PR 给所有 activation 路由加上了 skipRefresh: true,controller 的 skipRefresh 提前返回(workspace-extensions-controller.ts:678)先于唯一产生这些字段和该警告的 reconciliation 阶段执行,因此按此参考文档编码的集成方解析 activation 结果时会期待 result.refreshed/result.failed,或为 activation 处理 reconcile_slow,从而在每次 activation 上都会出错——daemon 不再产生这种形状。该示例现在还与本 PR 在此处新增的段落("在持久化策略提交后完成,不直接刷新活动 session")自相矛盾。建议把示例改为仍然执行 runtime reconciliation 的 operation(install/update/uninstall,相应调整 result.status),或保留 activation 示例但从其结果中移除 refreshed/failed,改用来自提交阶段的警告。

— qwen3.8-max via Qwen Code /review (v0.23.0)

`session_info` advertises `GET /workspace/:id/session-info` and its `/workspaces/:workspace/session-info` twin. The response aggregates persisted active and archived session counts without hydrating list metadata. It is an explicit O(n) disk scan and must not be polled; clients should treat `truncated: true` as a lower-bound result.

`session_approval_mode_control`, `workspace_tool_toggle`, `workspace_skill_settings_toggle`, `workspace_skill_settings_batch_toggle`, `extension_batch_activation_v2`, `workspace_init`, and `workspace_mcp_restart` advertise the mutation control routes documented below. Approval-mode control retains its non-strict compatibility gate. The other controls are strict-gated by operator authority: trusted-loopback primary, bearer-authenticated, or paired Local Control requests pass. A token-less primary request that reaches the strict gate without trusted-loopback authority returns 401 `token_required`; missing or invalid configured credentials and unpaired Local Control credentials are rejected earlier by bearer middleware with plain `401 Unauthorized`. Daemons that lack one of these routes return `404`. The settings-specific Skill tags are different: daemons from the retired-tag generation advertise `workspace_skill_toggle` and `workspace_skill_batch_toggle` and serve their catalog-validated contract at the same paths. The retired single-target route can return HTTP `404 skill_not_found` or `409 skill_not_toggleable`; the retired batch route returns HTTP 200 and places catalog-derived failures in `errors[]`. Pre-flight each tag before exposing its affordance, and do not infer the settings-specific Skill contract by probing route reachability. The route paths and request bodies did not change.
`session_approval_mode_control`, `workspace_tool_toggle`, `workspace_skill_settings_toggle`, `workspace_skill_settings_batch_toggle`, `extension_batch_activation_v2`, `extension_activation_explicit_refresh`, `workspace_init`, and `workspace_mcp_restart` advertise the mutation control routes documented below. Approval-mode control retains its non-strict compatibility gate. The other controls are strict-gated by operator authority: trusted-loopback primary, bearer-authenticated, or paired Local Control requests pass. A token-less primary request that reaches the strict gate without trusted-loopback authority returns 401 `token_required`; missing or invalid configured credentials and unpaired Local Control credentials are rejected earlier by bearer middleware with plain `401 Unauthorized`. Daemons that lack one of these routes return `404`. The settings-specific Skill tags are different: daemons from the retired-tag generation advertise `workspace_skill_toggle` and `workspace_skill_batch_toggle` and serve their catalog-validated contract at the same paths. The retired single-target route can return HTTP `404 skill_not_found` or `409 skill_not_toggleable`; the retired batch route returns HTTP 200 and places catalog-derived failures in `errors[]`. Pre-flight each tag before exposing its affordance, and do not infer the settings-specific Skill contract by probing route reachability. The route paths and request bodies did not change.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R1-2: [certifies-falsely] [new-surface] This changed paragraph lists extension_activation_explicit_refresh among tags that "advertise the mutation control routes documented below", next to "The other controls are strict-gated by operator authority" and "Daemons that lack one of these routes return 404". But the code registers this tag unconditionally as a behavior contract tied to no route (capabilities.ts:211, no CONDITIONAL_SERVE_FEATURES entry), and both refresh routes it relates to predate the tag and exist on older daemons that do not advertise it. A client spanning daemon generations that probes route reachability therefore sees "new contract present" on an older daemon and submits a second, compatibility-forbidden refresh after every activation (the paragraph at line ~332 says clients must not), while the mirror reading — tag absent plus "Daemons that lack one of these routes return 404" — implies the long-standing refresh routes 404 on older daemons, when they do not. Remove the tag from this sentence's list (the dedicated paragraph at line ~332 already documents it as a behavior contract and names both refresh routes), or add an explicit carve-out stating that this tag changes activation semantics and does not advertise a new route.

Witness:

BASE (7a3b6cb6): refresh routes registered (base-file lines 1453, 2660);
                 grep capabilities.ts for extension_activation_explicit_refresh -> exit 1 (tag absent)
PR   (1f06d58d): same routes at workspace-extensions.ts:1445/:2652; tag registered unconditionally
                 and added to this line-300 list
中文说明

本段修改把 extension_activation_explicit_refresh 列入"声明下述 mutation 控制路由"的 tag 清单,而上下文还有"其余控制受 operator 权限的 strict 门控"与"缺少这些路由的 daemon 返回 404"。但代码里该 tag 是无条件注册的行为契约,不对应任何路由(capabilities.ts:211,且不在 CONDITIONAL_SERVE_FEATURES 中),与之关联的两条 refresh 路由早在该 tag 之前就已存在,未声明该 tag 的旧 daemon 同样提供。跨 daemon 版本编码的客户端若以路由可达性探测,会在旧 daemon 上得到"新契约存在"的假象,进而在每次 activation 后重复提交一次兼容性所禁止的 refresh(约 332 行的段落明确禁止);反向误读同样成立——tag 缺失加上"缺少路由返回 404"会让人以为旧 daemon 上这两条长期存在的 refresh 路由不可用,而事实并非如此。建议把该 tag 从本句清单中移除(约 332 行的专门段落已把它定义为行为契约并点名两条 refresh 路由),或明确注明该 tag 只改变 activation 语义、不声明新路由。

— qwen3.8-max via Qwen Code /review (v0.23.0)

result: { status: 'disabled', name: 'test-ext' },
});
});
expect(bridge.extensionEvents).toEqual([]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3: This assertion pins the PR's new behavior — activation commits emit no extensions_changed event at all. The untouched event-schema reference docs/developers/daemon/09-event-schema.md (~line 119) still documents extensions_changed with status?: 'installed' | 'enabled' | 'disabled' | ..., and the SDK carries the same union, but after this change enabled/disabled are unreachable: every activation route takes the skipRefresh early-return before any broadcast site, and reconciler/explicit-refresh broadcasts carry no status. A client listening for those status values to update activation UI — the in-repo App.tsx toast branch renders extensions.manage.${change.status} for exactly them — silently never fires against new daemons. Convergence still arrives via the status-less reconciler broadcast, so the impact is a dead client branch plus a schema promise the daemon can no longer keep. Annotate the schema row (and the SDK union's doc) that enabled/disabled are only emitted by daemons without extension_activation_explicit_refresh.

Witness:

packages/cli server.test.ts -t 'commits extension enable and disable without refreshing sessions'
-> Tests 1 passed; asserts expect(bridge.extensionEvents).toEqual([]) after enable+disable
中文说明

该断言固定了本 PR 的新行为——activation 提交完全不再发出 extensions_changed 事件。但未被本 PR 修改的事件模式参考文档 docs/developers/daemon/09-event-schema.md(约 119 行)仍然把 extensions_changedstatus 写作 'installed' | 'enabled' | 'disabled' | ...,SDK 的类型并集同样如此;而本次改动之后 enabled/disabled 已不可达:所有 activation 路由都在任何广播点之前走 skipRefresh 提前返回,reconciler 与显式 refresh 的广播都不带 status。监听这些 status 值来更新 activation UI 的客户端(仓库内 App.tsx 的 toast 分支正是按 extensions.manage.${change.status} 渲染这两个值)在新 daemon 上会永远静默不触发。最终一致性仍会经由不带 status 的 reconciler 广播到达,因此影响是一个死分支加上一份 daemon 已无法兑现的模式承诺。建议在模式表格(及 SDK 并集的文档)中注明:enabled/disabled 只会由不支持 extension_activation_explicit_refresh 的 daemon 发出。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +191 to +194
expect(
container.querySelectorAll<HTMLButtonElement>('[role="combobox"]')[1]!
.disabled,
).toBe(false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-5: The disabled === false assertion in 'submits a workspace refresh without polling or blocking the page' cannot discriminate fire-and-forget from an awaited refresh: the refreshExtensionRuntime mock resolves immediately, so finally { setBusyName(null); } runs before this check either way. A void ...await ... mutation on the refresh call in setScopeActivation survives all four new tests — verified by mutation — so the "keeps the Extension page busy" regression would ship green, and on a real daemon a workspace refresh can take seconds (this PR's own motivation for decoupling). Make the refresh mock pending in this test (mockReturnValue(new Promise(() => {}))) and keep the existing assertions; with a never-settling refresh, an awaited variant leaves busyName set and this check fails.

Witness:

MUTANT (void->await): ExtensionsManagerPage.test.tsx -> Tests 4 passed (4)   # mutation survives
Probe, refresh mocked never-settling:
  mutant: AssertionError: expected true to be false   # combobox stays disabled
  intact: Tests 1 passed                              # fire-and-forget unlocks

The strengthened test is its own acceptance criterion: it must go red if the component's fire-and-forget call is changed to an awaited call — please remove the void and confirm the new test fails.

中文说明

'submits a workspace refresh without polling or blocking the page' 里 disabled === false 这条断言无法区分 fire-and-forget 与被 await 的 refresh:refreshExtensionRuntime 的 mock 立即 resolve,无论哪种写法 finally { setBusyName(null); } 都会在该检查之前执行。经变异验证,把 setScopeActivation 中 refresh 调用的 void ... 改为 await ... 后,全部 4 个新测试仍然通过——"页面保持忙碌"的回归会带着绿灯合入,而真实 daemon 上 workspace refresh 可能耗时数秒(这正是本 PR 解耦的动机)。请在该测试中把 refresh mock 改为挂起(mockReturnValue(new Promise(() => {})))并保留现有断言;在永不 settle 的 refresh 下,被 await 的变体会让 busyName 保持置位,该检查即会失败。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +1129 to +1133
if (activationRequiresExplicitRefresh) {
void workspace.client
.workspaceByCwd(workspace.workspaceCwd)
.refreshExtensionRuntime(connection.clientId)
.catch((error: unknown) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-6: This .catch writes the page's single shared message slot unconditionally, with no setMessageOwner update and nothing tracking the in-flight refresh. setBusyName(null) already ran in finally, so the busy guard lets a second mutation start while this refresh is still in flight; if the refresh then rejects late (a workspace refresh touches every live session and can take seconds or fail transiently), it clobbers the newer mutation's message. Reproduced by probe: extension A is disabled, its background refresh is held pending, mutation B fails with its own error, and A's refresh then rejects — the final UI text reports "Extension action succeeded, but session refresh failed" under B's row, i.e. success wording for a failed activation. Guard the catch against stale writes — capture a per-mutation generation id when the refresh is fired and only update the message if no newer mutation has started, or render refresh failures in a separate message slot.

Witness:

PROBE message after B failure contains boom-B: true
PROBE final text still reports B error (boom-B): false
PROBE final text reports success-despite-refresh-failure: true
# guarded variant flips the probe: the late rejection writes nothing, B's message survives

A fix is witnessed by a test where the first mutation's refresh rejects only after a second mutation completes with an error: the displayed message must remain the second mutation's error — removing the guard must turn it red.

中文说明

这个 .catch 会无条件写入页面唯一共享的消息槽,既不更新 setMessageOwner,也没有任何机制跟踪在途的 refresh。setBusyName(null) 已在 finally 中执行,忙碌守卫因此允许第二个变更操作在第一次 refresh 仍在进行时启动;若该 refresh 随后才失败(workspace refresh 会触碰所有活动 session,可能耗时数秒或瞬时失败),就会覆盖新操作的消息。探针复现:禁用 Extension A,其后台 refresh 挂起,随后操作 B 以自己的错误失败,A 的 refresh 此时才拒绝——最终 UI 文案在 B 的行下显示"Extension action succeeded, but session refresh failed",即对一个失败的 activation 显示成功措辞。请为 catch 增加过期写入防护——发起 refresh 时记录每次变更的代号,仅在没有更新的操作启动时才更新消息,或者把 refresh 失败渲染到独立的消息槽。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines 2641 to 2645
return { status: activation.effective, name: extension.name };
},
{
skipRefresh: true,
refreshRuntimes: [runtime],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-7: On the commit-only activation routes (this DELETE plus the two PUT sites and both batch sites), refreshRuntimes no longer triggers any refresh — the controller's only read of it sits after the skipRefresh early-return — but it is still silently load-bearing: sendOperation consumes it via mutationClientBridges(options.refreshRuntimes) to choose which bridges the X-Qwen-Client-Id header is validated against, and nothing at these call sites marks the option as still having an effect. A future cleanup deleting it as dead would make mutationClientBridges(undefined) fall back to workspaceRegistry?.listAll(), so a client id registered only on workspace A would pass validation for an activation mutation targeting workspace B, silently widening the per-workspace originator-identity grounding. Add a one-line comment here (or on the sendOperation option) stating that refreshRuntimes still selects the mutation-client validation bridge set under skipRefresh: true — or split the concern into a dedicated option.

Witness:

workspace-qualified-extensions.test.ts -t 'validates mutation clients against the targeted runtime set'
-> Tests 1 passed (a client id registered only on the wrong runtime gets 400 invalid_client_id)

Fix constraint: mutationClientBridges at packages/cli/src/serve/routes/workspace-extensions.ts:738-742(runtimes ?? workspaceRegistry?.listAll())?.map((runtime) => runtime.bridge) ?? [bridge]; any refactor must keep the workspace-scoped activation routes passing an explicit runtime set rather than hitting the all-workspaces fallback.

中文说明

在 commit-only 的 activation 路由上(此 DELETE 及两个 PUT 位点、两个 batch 位点),refreshRuntimes 不再触发任何 refresh——controller 对它的唯一读取位于 skipRefresh 提前返回之后——但它仍然是隐性起作用的:sendOperation 通过 mutationClientBridges(options.refreshRuntimes) 消费它,用以选择对 X-Qwen-Client-Id 头做校验的 bridge 集合,而这些调用点上没有任何标记说明该选项仍有效果。未来若有人把它当作死代码删除,mutationClientBridges(undefined) 会退回到 workspaceRegistry?.listAll(),于是只注册在 workspace A 的 client id 也能通过对 workspace B 的 activation 变更校验,悄然放宽按 workspace 划分的发起方身份约束。请在此处(或 sendOperation 的选项上)加一行注释,说明 skipRefresh: truerefreshRuntimes 仍用于选定 mutation 客户端校验的 bridge 集合——或把该关注点拆分为专用选项。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants